002.Next+React 双入口前端项目

背景

  1. SEO 需求

我们现在的网站是 CSR 渲染的,但有很多页面的内容密度较大(如:文献详情页、学者详情页),想要改造成 SSR 渲染以优化 SEO,增加产品曝光量

由 CSR 改造成 SSR 的成本并不小,基本方案是引入 Next.js,这需要对原代码从架构上进行改造

  1. 架构升级

我们现在的项目基本是和 Web 端强绑定的,如果想要扩展到小程序、移动端、桌面端是很难复用的

所以借由此契机,我们对整个项目的架构进行一些调整升级,以力求能够提高项目的健壮性、可复用性和可扩展性

现状

Pasted image 20260305160352.png

目标

Pasted image 20260305160418.png

  1. UI 层不处理任何业务逻辑,只做展示,需要具备 stateless 能力
  2. 业务逻辑层包括各种业务处理,数据处理,状态维护等
  3. 数据总线,各子模块之间的事件传递通过数据总线通信,下层不允许直接调用上层(通用层不可以调业务逻辑层),需要通过数据总线通信
  4. 除平台层,其他层禁止直接使用平台相关 API,如 window.xxx, wechat.xxx 等。可以预留接口,由平台层注入平台相关 API

想法

workspce 结构

├── bohrium-shared      # 通用层
├── bohrium-domains     # 业务层:上图中的业务逻辑层+业务UI层
├── bohrium-space       # 平台层:Web/H5 CSR
├── bohrium-next-app    # 平台层:Web/H5 SSR

# 之后可能会加
├── bohrium-wx-applet   # 平台层:微信小程序
├── bohrium-electron    # 平台层:桌面应用
├── bohrium-mobile      # 平台层:移动端

# 现在 Web/H5 都混在一个包中了,可以考虑单分一个 H5 包
├── bohrium-h5          # 平台层:H5
......

@bohrium/shared

通用层:业务无关、平台无关、无状态和副作用(hooks)

├── bohrium-shared
│   ├── ui/           # UI Widgets
│   │  ├── Button/        
│   │  └── Modal/                
│   ├── utils/        # 工具函数
│   │  └── EventBus/ # 事件总线
│   ├── constants/    # 常量
│   ├── types/        # TS 类型定义
│   ├── icons/        # icons

ui

  1. 只负责 UI 展示,无任何逻辑、状态和副作用
  2. 只是最基础的通用小组件,而不是业务组件

如下图中的 Text、Icon 等,而不是整个卡片

整个卡片应该放到业务层,再用这里的小组件“拼积木”

Pasted image 20260305160548.png

utils

事件总线:

// 发布订阅
type ObjectKey = string | number | symbol;
type AnyFunction = (...args: any[]) => any;

class EventBus {
    // Map<eventName, Map<originalCallback, callback>>
    // originalCallback 用于取消 once 注册的回调
    private events: Map<ObjectKey, Map<AnyFunction, AnyFunction>>;

    constructor() {
        this.events = new Map();
    }

    // 使用泛型的原因:...args 有类型,方便代码提示和类型检查
    emit<T extends AnyFunction>(eventName: ObjectKey, ...args: Parameters<T>) {
        const events = this.events.get(eventName);
        if (!events) {
            return;
        }
        events.forEach(callback => {
            callback(...args);
        });
    }

    on<T extends AnyFunction>(eventName: ObjectKey, callback: T) {
        this._on(eventName, callback, callback);
    }

    once<T extends AnyFunction>(eventName: ObjectKey, callback: T) {
        // 包装方法:执行一次后取消订阅
        const wrapper = (...args: Parameters<T>) => {
            callback(...args);
            this.off(eventName, callback);
        };
        this._on(eventName, wrapper, callback);
    }

    off<T extends AnyFunction>(eventName: ObjectKey, callback: T): boolean {
        const events = this.events.get(eventName);
        if (!events) {
            return false;
        }
        return events.delete(callback);
    }

    offAll(eventName: ObjectKey): boolean {
        return this.events.delete(eventName);
    }

    private _on(eventName: ObjectKey, callback: AnyFunction, originalCallback: AnyFunction) {
        let events = this.events.get(eventName);
        if (!events) {
            events = new Map();
            this.events.set(eventName, events);
        }
        events.set(originalCallback, callback);
    }
}

export const eventBus = new EventBus();

平台接口:

// 平台相关的 PAI 接口定义,代码中调用这个接口中的方法
export const platform = {
    // 打开新页面
    openNewPage(url: string | URL, opts: any) {},
    // 从本地存储取值
    getItemFromStorage<T>(key: string, opts: any): Promise<T> {},
    // ...
}
// 平台层注入具体实现

// web
function init() {
    platform.openNewPage = window.open
    platform.getItemFromStorage(key: string, opts: any) {
        const { target = 'local' } = opts || {}
        switch (target) {
            case 'local':
                return Promise.resolve(window.localStorage.getItem(key))
            // ...
        }
    }
}

// electron
function init() {
    platform.openNewPage = require('electron').shell.openExternal
    platform.getItemFromStorage(key: string, opts: any) {
        const { target = 'local' } = opts || {}
        switch (target) {
            case 'local':
                return require('fs').promises.readFile('/config.db')
            // ...
        }
    }
}

Hooks

事件总线:
export function useEventSubscribe(eventName: ObjectKey, callback: AnyFunction, once: boolean = false) {
    useEffect(() => {
        if (once) {
            eventBus.once(eventName, callback);
        } else {
            eventBus.on(eventName, callback);
        }
        return () => {
            eventBus.off(eventName, callback);
        };
    }, [eventName, callback, once]);
}



// 升级版
### ✅ 方案:多表 + 泛型表参数

#### 1️⃣ 按业务拆分事件定义


// events/auth.events.ts
export interface AuthEvents {
  login: { userId: number; userName: string };
  logout: undefined;
}

// events/theme.events.ts
export interface ThemeEvents {
  themeChanged: { mode: 'light' | 'dark' };
}

// events/order.events.ts
export interface OrderEvents {
  orderCreated: { orderId: string; amount: number };
  orderPaid: { orderId: string };
}


#### 2️⃣ 改造 Hook:把“表”变成泛型参数


// eventBus/useEventChannel.ts
import { useEventStore } from './eventStore';

export function useEventChannel<
  EMap extends Record<string, unknown>
>() {
  const setEvent = useEventStore((s) => s.setEvent);

  function trigger<K extends keyof EMap>(
    type: K,
    payload: EMap[K]
  ) {
    setEvent({ type: type as string, payload });
  }

  function subscribe<K extends keyof EMap>(
    type: K,
    handler: (payload: EMap[K]) => void
  ) {
    return useEventStore.subscribe(
      (state) => state.lastEvent,
      (latest) => {
        if (latest?.type === type) {
          handler(latest.payload as EMap[K]);
        }
      }
    );
  }

  return { trigger, subscribe };
}
懒加载组件
import { useEffect, useState } from "react"

export function useLazyComponent<T>(loader: () => Promise<{ default: T }>) {
  const [Component, setComponent] = useState<T | null>(null)

  useEffect(() => {
    let mounted = true
    loader().then(mod => {
      if (mounted) setComponent(mod.default)
    })
    return () => {
      mounted = false
    }
  }, [])

  return Component
}


// 示例
const UserForm = useLazyComponent(() => import("./UserFormView"))
return showForm && UserForm ? <UserForm ... /> : null

@bohrium/domains

业务层:上图中的业务逻辑层+业务UI层。平台无关

domains 翻译为 “领域”

当前用作 Interface、type、enum、constant 可能不太符合

业务包更符合语义

├── bohrium-domains
│   ├── biz/                 # 各个业务自己的逻辑
│   │  ├── knowledge-base/        
│   │  │   ├── home-page/   # 模块 1(以页面/功能划分)
│   │  │   │   ├── ui/  
│   │  │   │   ├── components/
│   │  │   │   ├── stores/
│   │  │   │   ├── hooks/
│   │  │   │   ├── utils/
│   │  │   │   ├── types/
│   │  │   │   └── constants/
│   │  │   ├── share-page/  # 模块 2(以页面/功能划分)
│   │  │   ├── ui/          # 当前业务通用的无状态、无副作用 UI
│   │  │   ├── components/  # 当前业务通用的有状态和副作用组件
│   │  │   ├── stores/      # 业务通用 zustand
│   │  │   ├── hooks/       # 业务通用
│   │  │   ├── utils/       # 业务通用
│   │  │   ├── types/       # 业务通用
│   │  │   ├── constants/   # 业务通用
│   │  │   ├── apis/        # 当前业务的所有的 api 都放这里,不分散到子模块
│   │  │   └── events/      # 事件总线的监听和触发统一到这里,不要散布到各处
│   │  ├── ai-search/ 
│   │  └── global-sidebar/                
│   ├── shared/              # 多个业务共享
│   │  ├── paper-card/      # 共享模块 1
│   │  ├── └── ui、stores、hooks ……
│   │  ├── constants/       # 如:后端返回的 code 码
│   │  └── ui、stores、hooks ……
│   ├── locales/             # i18n  

状态管理使用 zustand,去除所有 Context、Redux

biz

各个业务有自己的 UI、stores、hooks、utils、apis、types、constants

不同业务之间不要强耦合,尽量不直接 import 其他业务的代码,可以:

shared

多个业务都用到的组件、方法等

如:文献卡片、收藏弹窗

@bohrium/space

平台层

├── bohrium-space
│   ├── router/               
│   ├── pages/
│   ├── config/
│   ├── App.tsx
│   ├── white-screen-check.ts
│   ├── bootstrap.ts
│   ├── index.less

平台层如何复用业务层?

多个平台自由引用组合业务层代码,如:web/h5可以使用业务层UI/component;mobile可能没有DOM,无法使用UI/component,但可以使用 utils、icon……

UI VS. component

UI

纯函数:(props) -> JSX:输入的 props 相同,输出的 JSX 永远相同

输入只靠 props,内部没有自己的 State,也不产生任何副作用

props 分为:输入、输出事件(onClick)

components

可以有自己状态,可以产生副作用

示例

一眼区分

UI 和 components 放到不同的文件夹下

Import 的代码要想知道是 UI/components 需要看引入路径,还是比较麻烦的

建议所有UI都以 UI 结尾,components 无需特殊标识,这样就可以一眼区分了

如:

  • import { UserListViewUI } from "./UserListView" -> ui

  • import { UserListPage } from "@/components/UserListPage" -> components

  • -> components
  • // ui: 无状态、无副作用
    import { UserListViewUI } from "./UserListView"
    import { UserFormViewUI } from "./UserFormView"
    
    type Props = {
      users: { id: string; name: string; email: string }[]
      showForm: boolean
      onAddClick: () => void
      onCancel: () => void
      onSubmit: (form: { name: string; email: string }) => void
    }
    
    export function UserListContainerUI({
      users,
      showForm,
      onAddClick,
      onCancel,
      onSubmit
    }: Props) {
      return (
        <div>
          <UserListViewUI users={users} onAddClick={onAddClick} />
          {showForm && <UserFormViewUI onSubmit={onSubmit} onCancel={onCancel} />}
        </div>
      )
    }
    
    // component
    import { useUserListPage } from "@/hooks/useUserListPage"
    import { UserListContainerUI } from "../ui/UserListContainerUI"
    
    export function UserListPage() {
      // state
      const { state, actions } = useUserListPage()
      
      // 副作用
      useEffect(() => {
          window.title = 'User List'
      }, [])
    
      return (
        <UserListContainerUI
          users={state.users}
          showForm={state.showForm}
          onAddClick={actions.openForm}
          onCancel={actions.closeForm}
          onSubmit={actions.addUser}
        />
      )
    }
    

    事件总线

    // 定义各个业务暴露的事件总线 key
    
    const AI_SEARCH_KEY = 'ai-search'
    const AI_SEARCH_EVENT = {
        // 做搜索
        doSearch(value: string): boolean {}, 
    }
    export const AI_SEARCH_EVENTS = Object.keys(AI_SEARCH_EVENT).reduce((prev, curr) => {
        prev[curr] = `${AI_SEARCH_KEY}:${curr}`
    }, {})
    // {
    //     'doSearch': 'ai-search:doSearch'
    // }
    
    const KNOWLEDGE_BASE_KEY = 'knowledge-base'
    const KNOWLEDGE_BASE_EVENT = {
        // 打开 Zotero 导入框
        openZoteroImportModal(open: boolean): void {},
    }
    export const KNOWLEDGE_BASE_EVENTS = Object.keys(AI_SEARCH_EVENT).reduce((prev, curr) => {
        prev[curr] = `${KNOWLEDGE_BASE_KEY}:${curr}`
    }, {})
    
    // 每个业务在 /events/listen.ts 监听暴露的方法
    eventBus.on(KNOWLEDGE_BASE_EVENTS.openZoteroImportModal, (open: boolean) => {
        store.setZoteroImportModalOpen(open)
    })
    
    // 每个业务在 /events/call.ts  文件夹列举会调用其他业务的条目
    // 而不是把通过事件总线触发其他业务的逻辑分布到代码各处
    // 这样看这一个文件就能知道当前业务耦合了哪些业务
    export function openZoteroImportModal(open: boolean) {
        eventBus.emit(KNOWLEDGE_BASE_EVENTS.openZoteroImportModal, open)
    }
    
    // 业务其他地方调用这个方法
    

    问题:CSR和SSR是两个项目,上面实现的EventBus是基于内存的,两个项目不共享

    解决:web 平台考虑改成基于 https://developer.mozilla.org/zh-CN/docs/Web/API/BroadcastChannel

    缓存:

    https://github.com/vercel/next.js/tree/canary/examples/cache-handler-redis

    Antd in Nextjs

    https://ant-design.antgroup.com/docs/react/use-with-next-cn

    落地实践

    注意

    1. 所有服务器请求接口或者其他可能失败的操作都要加catch,客户端发现没有这个数据再请求一次,否则页面直接展示服务端错误
    2. bohrium-next-app下的xxx/page.ts中没有标记"use client"的第一行要加 await initServer()
    3. context.current.request方法,isCata=true使用catalystPlusAxios,isAgent=true使用AgentAxios,否则使用Axios

    SSR灰度策略

    路由匹配

    Pasted image 20260305160733.png

    域名匹配

    使用场景比如:机构和非机构走不同的规则

    不传默认匹配所有域名

    灰度规则

    1. 全走SSR
    rules: ["match"]
    
    1. 不走SSR
    rules: ["mismatch"]
    
    1. 30% 灰度
    rules: [{ type: "userIdSuffix", value: [1, 2, 3] }]
    
    1. 固定userId灰度
    rules: [{ type: "userIdExact", value: [16782, 16783] }]
    
    1. 未登录是否走灰度
    rules: [{ type: "noneUserId", value: 'match' | 'mismatch' }] // 默认mismatch
    

    注意:全走 SSR 要写 rules: ["match"],而不是删掉这条规则,删掉的意思是不走SSR

    dev server

    要同时启动SSR和CSR项目,并监听一个端口

    使用

    # 项目根目录运行
    pnpm install
    pnpm run dev
    
    # 浏览器访问 localhost:3002
    

    原理

    启一个Node.js服务,根据url代理两个项目的dev server并转发请求

    "dev": "concurrently
            \"pnpm -r --filter=@bohrium/space run start:dev\" # CSR dev server :3000
            \"pnpm -r --filter=@bohrium/next run dev\" # SSR dev server :3001
            \"node ./deploy/dev.js" # Node.js代理服务器 :3002
            --kill-others-on-fail",
    
    const express = require('express');
    const { createProxyMiddleware } = require('http-proxy-middleware');
    const app = express();
    const PORT = 3002;
    
    // 代理中间件包装器(添加调试信息)
    const createDebugProxy = (target, name) => {
        return createProxyMiddleware({
            target,
            changeOrigin: true,
            ws: true,
            logLevel: 'debug',
            on: {
                error: (err, req, res) => {
                    console.error(`[${name} 代理错误] ${err.message}`);
                    res.status(502).send(`${name} 服务不可用`);
                },
                proxyReq: (proxyReq, req, res) => {
                    console.log(`[${name}] 代理请求: ${req.method} ${req.url}`);
                },
                proxyRes: (proxyRes, req, res) => {
                    console.log(`[${name}] 收到响应: ${req.url} -> ${proxyRes.statusCode}`);
                },
            },
        });
    };
    
    const nextProxy = createDebugProxy('http://localhost:3001', 'Next.js');
    const reactProxy = createDebugProxy('http://localhost:3000', 'React');
    
    const nextRoutePattern = /^\/(_next|__nextjs|patent-details)(\/|$)/;
    
    // 动态路由分配
    app.use((req, res, next) => {
        console.log(`[入口] 请求: ${req.method} ${req.url}`);
    
        if (nextRoutePattern.test(req.path)) {
            console.log(`[路由] ${req.path} 匹配 Next.js 规则`);
            nextProxy(req, res, next);
        } else {
            console.log(`[路由] ${req.path} 代理到 React`);
            reactProxy(req, res, next);
        }
    });
    
    const server = app.listen(PORT, () => {
        console.log(`代理服务器运行在 http://localhost:${PORT}`);
        console.log('根路径 -> React 应用');
    });
    
    server.on('upgrade', (req, socket, head) => {
        console.log(`[WS] WebSocket 升级: ${req.url}`);
    
        if (nextRoutePattern.test(req.url)) {
            console.log(`[WS] 代理到 Next.js`);
            nextProxy.upgrade(req, socket, head);
        } else {
            console.log(`[WS] 代理到 React`);
            reactProxy.upgrade(req, socket, head);
        }
    });
    
    // 捕获未处理异常
    process.on('uncaughtException', err => {
        console.error('未捕获异常:', err);
    });
    

    常见功能

    Pasted image 20260305160800.png

    类型定义(可能不是最新,最新见bohrium-shared/src/context.ts):

    export type GlobalContextType = {
        request: <T extends { code: number; data: unknown } = { code: number; data: unknown }>(
            method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH',
            url: string,
            options?: {
                headers?: Record<string, string>;
                params?: any;
                paramsSerializer?: ParamsSerializerOptions | CustomParamsSerializer;
                body?: RequestInit['body'] | Record<string, any>;
                signal?: AbortSignal;
                timeout?: number;
                withCredentials?: boolean;
                baseUrl?: string;
                isCata?: boolean;
                skipAuthentication?: boolean;
                toastFail?: boolean,
                refreshAfterLogin?: boolean,
            }
        ) => Promise<T>;
        useRouter: () => {
            pathname: string;
            searchParams: Record<string, string>;
            replace: (url: string | 'not-found') => void;
            push: (url: string | 'not-found') => void;
            back: () => void;
            forward: () => void;
            reload: () => void;
            canBack: () => boolean;
        };
        isMobile: () => boolean;
        getUserInfo: () => IUserInfo | null;
        getToken: () => string | null;
        getEnv: () => Env | null;
        syncStorage: (type?: 'local') => {
            setItem: (key: string, value: string) => void;
            getItem: (key: string) => string | null;
            removeItem: (key: string) => void;
        };
    };
    

    使用:

    import { getContext } from '@bohrium/shared/index';
    import useIsChinese from '@bohrium/shared/hooks/useIsChinese';
    import useLang from '@bohrium/shared/hooks/useLang';
    import useLocale from '@bohrium/shared/hooks/useLocale';
    
    const context = getContext();
    
    export default function JournalListPage(props: JournalListPageProps) {
        // 是否中文
        const isChinese = useIsChinese();
        
        // export enum LANGUAGE {
        //   CHINESE = 'zh-cn',
        //   ENGLISH = 'en-us',
        // }
        const lang = useLang();
        
        // export enum LOCALE {
        //   ZH = 'zh',
        //   EN = 'en'o
        // }
        const locale = useLocale();
        
        // 路由相关,hook:不可放入循环和条件语句
        const router = context.current.useRouter();
        const isMobile = context.current.useMobile();
        
        // userInfo,未登录为null
        const userInfo = context.current.getUserInfo();
    
        return <div>123</div>;
    }
    

    导入其他包

    比如在demains包中:

    export const a = '11'
    

    想要在next-app包中导入:

    1. 配置next-app包的tsconfig.json:
    {
      "$schema": "https://json.schemastore.org/tsconfig",
      "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["./src/*"],
            "@bohrium/shared/*": ["../bohrium-shared/src/*"],
            "@bohrium/domains/*": ["../bohrium-domains/src/*"],
        },
        // 其他配置
      },
      // 其他配置
    }
    
    1. 导入
    import { a } from '@bohrium/domains/biz/journal-library';
    
    console.log('~~~~~!a', a);
    

    i18n

    注意事项:

    1. 写next的页面需要添加动态加载命名空间的逻辑
    2. 在 useEffect、useCallback、useMemo 使用到 t('xx') 的地方要把 t 写入依赖数组中
    3. tolgee key禁止使用中文,en文案value禁止使用中文
    4. tolgee命名空间规划合理,宁可在多个命名空间冗余,也不要跨命名空间使用

    详见:Next.js i18n 文案加载优化

    i18n 文件统一放在 bohrium-domains/src/locales 文件夹下,通过 Tolgee 管理

    // 在bohrium-domains目录下
    
    // 拉取
    tolgee pull
    // 同步
    tolgee sync
    

    客户端组件使用:

    "use client"
    
    import { useTranslate } from '@tolgee/react';
    
    export default function JournalCardUI(props: JournalCardUIProps) {
        const { t } = useTranslate('journal');
    
        return (
            <div>
                {t('test')}
            </div>
        );
    }
    

    服务端组件使用:

    // @bohrium/next 包传给 @bohrium/domain 包
    
    // next包下代码
    import { getLocale, getTranslate } from '@/locales/tolgee/server';
    
    export default async function Page() {
        const t = await getTranslate('journal');
        return <JournalCardUI t={t} />
    }
    
    
    // domain包下代码
    export default function JournalCardUI(props: JournalCardUIProps) {
        const { t } = props;
    
        return (
            <div>
                {t('test')}
            </div>
        );
    }
    

    请求接口

    import { request } from '@bohrium/domains/shared/utils/request';
    
    export const subscribeLibraryJournal = (publicationId: number) => {
        return request('POST', '/catalystPlus/subscribe/auth/journal', { body: { publicationId } });
    };
    
    export const unsubscribeLibraryJournal = (publicationId: number) => {
        return request('POST', '/catalystPlus/subscribe/auth/journal/cancel', { body: { publicationId } });
    };
    

    客户端组件使用:

    import { useMutation, useQuery } from 'react-query';
    
    export default function JournalSubscribeBtn({
        item,
        refresh,
        subscribeBtnProps = { type: 'text' },
        isRound = true,
        customStyles = {},
        subscribeText,
    }: JournalSubscribeBtnProps) {
        const { mutateAsync } = useMutation({
            mutationFn: (params: { publicationId: number; isSubscribe: boolean }) => {
                const { isSubscribe, publicationId } = params;
                return isSubscribe ? subscribeLibraryJournal(publicationId) : unsubscribeLibraryJournal(publicationId);
            },
        });
    }
    

    服务端组件使用:

    // @bohrium/next 包传给 @bohrium/domain 包
    
    // next包下代码
    import { subscribeLibraryJournal } from '@bohrium/domain/biz/journal-library/apis/subscribe.ts';
    
    export default async function Page() {
        const data = await subscribeLibraryJournal(1);
        return <JournalCardUI data={data} />
    }
    
    
    // domain包下代码
    export default function JournalCardUI(props: JournalCardUIProps) {
        const { data } = props;
    
        return (
            <div>
                {data}
            </div>
        );
    }
    

    Icon

    将svg文件放入bohrium-shared/src/icons/svg目录下,进入bohrium-shared目录运行pnpm run generate-icons,会在bohrium-shared/src/icons/svg-components目录生成对应组件,使用该组件

    详见:bohrium-shared/README.md

    import { OutlinedBellCheckIcon, OutlinedBellPlusIcon } from '@bohrium/shared/icons';
    
    <OutlinedBellCheckIcon className={isRound ? 'text-color-success' : ''} />
    

    查看已有Icon:

    pnpm -r --filter=@bohrium/shared run storybook
    

    使用环境变量

    配置:

    /**
     * @type {import('next').NextConfig}
     */
    const nextConfig = {
        env: {
            PUBLIC_URL: 'https://cdn1.deepmd.net/bohrium/web',
            CDN_URL: 'https://cdn1.deepmd.net',
        },
        // 其他配置
    };
    module.exports = nextConfig;
    

    使用:

    export const getJournalDefaultCover = (journalName: string) => {
        const defaultCovers = [
            `${process.env.CDN_URL}/static/img/6f049096journal_1.png`,
            `${process.env.CDN_URL}/static/img/779ecc90journal_2.png`,
            `${process.env.CDN_URL}/static/img/9f326df1journal_3.png`,
            `${process.env.CDN_URL}/static/img/57c0c767journal_4.png`,
            `${process.env.CDN_URL}/static/img/d8a802f9journal_5.png`,
        ];
        return defaultCovers[journalName?.slice(-1)?.charCodeAt(0) % 5];
    };